재시도에 지수 백오프와 지터가 필요한 이유
재시도에 지수 백오프와 지터가 필요한 이유
재시도는 실패한 요청을 성공으로 바꿀 기회를 주는 대신 요청 수와 지연을 늘린다. 재시도 가능한 오류와 멱등한 작업에만 적용하고, 지수 백오프로 시도 간격을 늘리며 지터로 여러 클라이언트의 재시도 시점을 흩어야 한다. 최대 횟수보다 전체 deadline과 재시도 예산을 먼저 정한다.
목차
- #재시도가 장애를 더 크게 만드는 순간
- #재시도하기 전에 세 가지를 확인한다
- #고정 간격 재시도가 동기화되는 이유
- #지수 백오프로 실패 서비스에 시간을 준다
- #지터로 재시도 파동을 흩는다
- #최대 횟수보다 전체 시간 예산이 중요하다
- #HTTP 오류를 재시도 가능성으로 분류한다
- #Retry-After와 서버의 과부하 신호를 존중한다
- #여러 계층의 재시도가 증폭되지 않게 한다
- #취소 가능한 TypeScript 재시도 함수
- #재시도와 멱등성을 함께 설계한다
- #관측과 테스트
- #마무리
- #참고 자료
- #관련 노트
재시도가 장애를 더 크게 만드는 순간
외부 상품 API가 일시적으로 503 Service Unavailable을 반환한다고 하자.
async function loadProduct(
productId: string,
): Promise<Product> {
while (true) {
try {
return await productApi.get(productId);
} catch {
// 성공할 때까지 즉시 재시도
}
}
}
실패가 아주 드물고 한 번 더 호출하면 성공한다면 재시도는 유용하다. 하지만 서버가 과부하 때문에 실패하는 상황에서는 각 원래 요청이 여러 추가 요청을 만든다.
정상 요청 1,000개/초
실패율 증가
각 요청이 즉시 3번 재시도
최대 4,000개/초의 호출 시도
이미 처리 능력을 넘은 서버에 더 많은 요청이 도착한다. 응답이 느려지면서 클라이언트 연결과 Promise가 쌓이고, 상위 서버의 메모리와 소켓까지 소모된다. 재시도가 회복 수단이 아니라 장애 증폭기가 된다.
flowchart LR
A[서버 과부하] --> B[timeout·503 증가]
B --> C[클라이언트 즉시 재시도]
C --> D[요청량 증가]
D --> A한 번의 추가 시도는 성공 확률을 높이지만 서버 부하, 사용자 지연, 연결 점유, 중복 부작용 가능성도 함께 높인다.
재시도 정책에는 적어도 다음 질문의 답이 있어야 한다.
무엇을 재시도하는가?
얼마나 기다리는가?
언제 완전히 포기하는가?
같은 작업이 두 번 실행되어도 안전한가?
어느 계층이 재시도를 책임지는가?
재시도하기 전에 세 가지를 확인한다
백오프 공식을 고르기 전에 오류와 작업의 성질을 확인한다.
1. 다음 시도에서 결과가 달라질 수 있는가
일시적인 네트워크 단절, rate limit, 서버 과부하는 시간이 지나면 회복될 수 있다. 잘못된 요청 형식이나 권한 부족은 같은 입력으로 다시 보내도 대개 달라지지 않는다.
| 실패 | 재시도 가능성 | 이유 |
|---|---|---|
| 연결 초기화 실패 | 조건부 가능 | 일시 네트워크 장애일 수 있음 |
| 요청 timeout | 조건부 가능 | 서버 결과는 불확실함 |
| HTTP 429 | 가능 | 제한 시간 후 허용될 수 있음 |
| HTTP 502·503·504 | 대체로 가능 | 중간 계층·서버의 일시 장애 가능 |
| HTTP 400 | 대체로 불가 | 요청을 고쳐야 함 |
| HTTP 401·403 | 대체로 불가 | 자격 증명·권한 문제 |
| HTTP 404 | 업무에 따라 다름 | 지연 복제라면 잠시 후 나타날 수 있음 |
| 스키마 검증 오류 | 불가 | 코드나 데이터 수정 필요 |
HTTP 상태 코드만으로 충분하지 않다. 결제 공급자가 500을 반환했지만 실제 결제는 완료했을 수 있다. 공급자의 API 계약과 오류 코드를 함께 봐야 한다.
2. 작업이 멱등한가
같은 요청을 여러 번 수행해도 결과가 같거나 중복을 제거할 수 있어야 한다.
PUT /profiles/user-example
Idempotency-Key: profile-update-example-42
조회는 일반적으로 안전하지만, “주문 생성”이나 “포인트 차감”은 별도 Idempotency Key 없이 재시도하면 중복 부작용이 생길 수 있다.
3. 남은 시간이 있는가
상위 HTTP 요청의 deadline이 2초 남았는데 각 시도가 2초 timeout이고 세 번 재시도하면 결과를 사용자에게 돌려줄 수 없다.
전체 예산 2,000ms
├─ 첫 시도 800ms
├─ backoff 200ms
├─ 두 번째 시도 700ms
└─ 응답 직렬화·전송 300ms
시도 횟수가 남았더라도 deadline이 부족하면 중단한다.
고정 간격 재시도가 동기화되는 이유
모든 클라이언트가 실패 1초 뒤 재시도하도록 설정했다고 하자.
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await operation();
} catch {
await sleep(1000);
}
}
서버 장애가 많은 클라이언트에 동시에 보이면 실패 시점도 비슷하다.
00.0초: 1,000개 요청 실패
01.0초: 1,000개 동시 재시도
02.0초: 다시 1,000개 동시 재시도
03.0초: 다시 1,000개 동시 재시도
이를 동기화된 재시도 파동 또는 thundering herd 문제로 볼 수 있다.
호출량
1000 |● ● ● ●
|
500 |
|
0 |----●-------●-------●--------
0 0.5 1 1.5 2 2.5 3초
서버가 조금 회복했더라도 다음 파동이 한 번에 도착해 다시 과부하가 된다. 고정 간격을 길게 바꿔도 파동 간격만 늘 뿐 동기화 자체는 남는다.
지수 백오프로 실패 서비스에 시간을 준다
지수 백오프는 시도가 반복될수록 대기 시간을 늘린다.
delay = baseDelay × 2^attempt
기본 지연이 500ms라면 다음과 같다.
| 실패 후 대기 | 계산 | 지연 |
|---|---|---|
| 첫 번째 재시도 | 500 × 2⁰ | 500ms |
| 두 번째 재시도 | 500 × 2¹ | 1,000ms |
| 세 번째 재시도 | 500 × 2² | 2,000ms |
| 네 번째 재시도 | 500 × 2³ | 4,000ms |
function exponentialDelay(
attempt: number,
baseDelayMs: number,
): number {
return baseDelayMs * 2 ** attempt;
}
실패가 계속되면 값이 빠르게 커지므로 상한을 둔다.
function cappedExponentialDelay(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
): number {
return Math.min(
maxDelayMs,
baseDelayMs * 2 ** attempt,
);
}
상한이 30초면 이후 시도는 모두 30초를 기준으로 한다.
0.5s → 1s → 2s → 4s → 8s → 16s → 30s → 30s
백오프는 서버에 회복할 시간을 주고 클라이언트의 자원 소비를 낮춘다. 그러나 모든 클라이언트가 같은 공식과 같은 시작 시각을 사용하면 여전히 1초, 2초, 4초에 함께 몰린다. 그래서 지터가 필요하다.
지터로 재시도 파동을 흩는다
지터는 대기 시간에 무작위성을 더한다. 단순히 고정된 ±10ms를 더하기보다 백오프 구간 전체를 분산하는 전략이 효과적이다.
Full Jitter
계산된 상한 cap 사이에서 무작위 값을 선택한다.
cap = min(maxDelay, baseDelay × 2^attempt)
delay = random(0, cap)
function fullJitterDelay(input: {
attempt: number;
baseDelayMs: number;
maxDelayMs: number;
random?: () => number;
}): number {
const random = input.random ?? Math.random;
const cap = Math.min(
input.maxDelayMs,
input.baseDelayMs * 2 ** input.attempt,
);
return Math.floor(random() * cap);
}
두 번째 재시도의 상한이 2초라면 각 클라이언트는 0–2초 사이에 흩어진다.
Equal Jitter
최소한 절반은 기다리고 나머지 절반을 무작위로 정한다.
delay = cap / 2 + random(0, cap / 2)
function equalJitterDelay(
cap: number,
random: () => number = Math.random,
): number {
const half = cap / 2;
return Math.floor(half + random() * half);
}
너무 짧은 즉시 재시도를 줄이는 대신 평균 대기 시간이 Full Jitter보다 길다.
Decorrelated Jitter
이전 지연을 기준으로 다음 범위를 정한다.
delay = min(maxDelay, random(baseDelay, previousDelay × 3))
function decorrelatedJitterDelay(input: {
previousDelayMs: number;
baseDelayMs: number;
maxDelayMs: number;
random?: () => number;
}): number {
const random = input.random ?? Math.random;
const upper = Math.max(
input.baseDelayMs,
input.previousDelayMs * 3,
);
return Math.min(
input.maxDelayMs,
Math.floor(
input.baseDelayMs +
random() * (upper - input.baseDelayMs),
),
);
}
어떤 전략이 항상 정답인 것은 아니다. 단순하고 널리 설명되는 Full Jitter를 기본으로 시작하고 SDK가 검증된 정책을 제공한다면 직접 구현보다 해당 옵션을 우선 검토한다.
Math.random()은 재시도 시점을 분산하기에는 사용할 수 있지만 비밀 토큰이나 보안 키 생성에는 적합하지 않다.
최대 횟수보다 전체 시간 예산이 중요하다
maxAttempts: 5만 두면 각 시도 timeout과 백오프에 따라 총 시간이 크게 달라진다.
시도 timeout 3초 × 5회 = 최대 15초
백오프 = 0.5 + 1 + 2 + 4초
총 약 22.5초 + 네트워크·코드 오버헤드
사용자 요청 SLA가 5초라면 이미 맞지 않는다. 전체 deadline을 절대 시각으로 전달한다.
type Deadline = Readonly<{
expiresAt: number;
}>;
function remainingMs(deadline: Deadline): number {
return Math.max(
0,
deadline.expiresAt - Date.now(),
);
}
각 시도 전에 남은 시간을 확인한다.
const remaining = remainingMs(deadline);
if (remaining < minimumAttemptBudgetMs) {
throw new DeadlineExceededError();
}
시도 timeout은 남은 시간보다 짧게 둔다.
const attemptTimeoutMs = Math.min(
configuredAttemptTimeoutMs,
remaining - responseReserveMs,
);
responseReserveMs는 상위 응답 직렬화와 전송, 정리 작업에 남겨 둘 예산이다.
최대 시도와 deadline을 함께 사용한다
- 최대 시도: 오류가 매우 빠르게 발생할 때 호출 폭주 방지
- deadline: 느린 실패와 백오프를 포함한 총 지연 제한
- 최대 단일 지연: 장시간 잠든 작업이 큐를 점유하지 않게 제한
셋은 서로 대체하지 않는다.
HTTP 오류를 재시도 가능성으로 분류한다
오류 분류는 재시도 함수 바깥의 도메인 정책으로 둔다.
type RetryDecision =
| { retry: false; reason: string }
| {
retry: true;
reason: string;
retryAfterMs?: number;
};
function classifyHttpFailure(
error: unknown,
): RetryDecision {
if (error instanceof RequestValidationError) {
return {
retry: false,
reason: "invalid_request",
};
}
if (error instanceof HttpResponseError) {
const status = error.status;
if (status === 429) {
return {
retry: true,
reason: "rate_limited",
retryAfterMs:
parseRetryAfter(error.headers),
};
}
if ([502, 503, 504].includes(status)) {
return {
retry: true,
reason: "temporary_upstream_failure",
};
}
return {
retry: false,
reason: `http_${status}`,
};
}
if (
error instanceof TypeError ||
error instanceof NetworkError
) {
return {
retry: true,
reason: "network_failure",
};
}
return {
retry: false,
reason: "unknown_failure",
};
}
실제 fetch의 네트워크 오류 타입과 라이브러리별 오류 모양은 다를 수 있다. 오류 이름 문자열만 비교하지 말고 사용하는 클라이언트의 공식 계약을 확인한다.
Timeout은 결과 불확실 상태다
클라이언트가 응답을 기다리다 timeout이 났다고 서버가 처리하지 않은 것은 아니다.
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: 주문 생성
S->>DB: 주문 COMMIT
C--xS: 클라이언트 timeout
Note over C: 실패처럼 보임
C->>S: 같은 요청 재시도
Note over S: 멱등성 키 없으면 중복 생성 가능쓰기 요청은 재시도 가능 오류인지와 별개로 Idempotency Key가 필요하다.
Retry-After와 서버의 과부하 신호를 존중한다
서버는 429나 503 응답에 Retry-After를 포함할 수 있다.
HTTP/1.1 429 Too Many Requests
Retry-After: 5
값은 초 단위 숫자 또는 HTTP 날짜일 수 있다.
function parseRetryAfter(
headers: Headers,
now: number = Date.now(),
): number | undefined {
const value = headers.get("retry-after");
if (!value) {
return undefined;
}
const seconds = Number(value);
if (
Number.isFinite(seconds) &&
seconds >= 0
) {
return seconds * 1000;
}
const date = Date.parse(value);
if (Number.isNaN(date)) {
return undefined;
}
return Math.max(0, date - now);
}
서버가 준 값과 지수 백오프를 어떻게 조합할지 정책을 정한다. 보수적으로 더 긴 값을 선택할 수 있다.
const delayMs = Math.max(
calculatedBackoffMs,
decision.retryAfterMs ?? 0,
);
하지만 악의적이거나 잘못된 외부 서버가 지나치게 긴 값을 줄 수 있으므로 전체 deadline과 최대 지연 상한은 유지한다.
const boundedDelayMs = Math.min(
delayMs,
maxDelayMs,
remainingMs(deadline),
);
여러 계층의 재시도가 증폭되지 않게 한다
API Gateway, 서비스 A, 서비스 B, DB 클라이언트가 각각 최대 3회 시도하면 호출 수가 곱해질 수 있다.
Gateway 3회
└─ Service A 3회
└─ Service B 3회
└─ DB 3회
최악의 DB 시도 수 = 3⁴ = 81
각 계층은 자신의 재시도만 보면 작아 보이지만 전체로는 장애를 증폭한다.
flowchart TD
G[Gateway attempt] --> A1[Service A attempt 1]
G --> A2[Service A attempt 2]
A1 --> B1[Service B attempts]
A2 --> B2[Service B attempts]
B1 --> D1[DB attempts]
B2 --> D2[DB attempts]재시도 책임을 한 계층에 모은다.
- 가장 많은 문맥을 가진 상위 계층에서 전체 작업 재시도
- SDK 내부의 짧은 전송 재시도를 사용하면 상위 횟수 축소
- 트랜잭션 충돌처럼 DB 클라이언트가 아는 오류는 트랜잭션 경계에서 재시도
- 이미 큐가 재전달한다면 Worker 내부의 무한 재시도 제거
SDK가 기본 재시도를 제공하는지 모르고 바깥에서 다시 감싸는 경우가 흔하다. 실제 최대 시도 수를 로그와 부하 테스트로 확인한다.
재시도 예산
전체 요청 중 재시도가 차지할 수 있는 비율을 제한할 수도 있다.
정상 호출 성공률이 나빠질수록
→ 재시도 토큰 소모 증가
→ 예산 소진 뒤 빠른 실패
→ 의존성에 추가 부하 제한
프로세스 또는 대상 서비스별 토큰 버킷으로 구현할 수 있다. Circuit Breaker와의 차이는 다음 글에서 다룬다.
취소 가능한 TypeScript 재시도 함수
앞의 규칙을 예시 코드로 조립한다. 특정 프로젝트 코드를 가져온 것이 아닌 재구성된 구현이다.
import {
setTimeout as sleep,
} from "node:timers/promises";
type RetryPolicy = Readonly<{
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
attemptTimeoutMs: number;
minimumAttemptBudgetMs: number;
responseReserveMs: number;
}>;
type RetryContext = Readonly<{
attempt: number;
signal: AbortSignal;
timeoutMs: number;
}>;
상위 취소 신호와 시도 timeout을 합친다.
function createAttemptSignal(
parentSignal: AbortSignal,
timeoutMs: number,
): AbortSignal {
return AbortSignal.any([
parentSignal,
AbortSignal.timeout(timeoutMs),
]);
}
재시도 함수는 오류 분류를 주입받는다.
async function retry<T>(input: {
operation:
(context: RetryContext) => Promise<T>;
policy: RetryPolicy;
deadline: Deadline;
signal: AbortSignal;
classify:
(error: unknown) => RetryDecision;
random?: () => number;
onAttemptFailed?: (event: {
attempt: number;
decision: RetryDecision;
nextDelayMs?: number;
error: unknown;
}) => void;
}): Promise<T> {
let lastError: unknown;
for (
let attempt = 0;
attempt < input.policy.maxAttempts;
attempt += 1
) {
input.signal.throwIfAborted();
const remaining =
remainingMs(input.deadline);
if (
remaining <
input.policy.minimumAttemptBudgetMs
) {
throw new DeadlineExceededError({
cause: lastError,
});
}
const timeoutMs = Math.min(
input.policy.attemptTimeoutMs,
Math.max(
1,
remaining -
input.policy.responseReserveMs,
),
);
try {
return await input.operation({
attempt,
timeoutMs,
signal: createAttemptSignal(
input.signal,
timeoutMs,
),
});
} catch (error) {
lastError = error;
const decision = input.classify(error);
const isLastAttempt =
attempt + 1 >= input.policy.maxAttempts;
if (!decision.retry || isLastAttempt) {
input.onAttemptFailed?.({
attempt,
decision,
error,
});
throw error;
}
const backoffMs = fullJitterDelay({
attempt,
baseDelayMs:
input.policy.baseDelayMs,
maxDelayMs:
input.policy.maxDelayMs,
random: input.random,
});
const requestedDelayMs = Math.max(
backoffMs,
decision.retryAfterMs ?? 0,
);
const availableForDelay = Math.max(
0,
remainingMs(input.deadline) -
input.policy.minimumAttemptBudgetMs,
);
const nextDelayMs = Math.min(
requestedDelayMs,
input.policy.maxDelayMs,
availableForDelay,
);
input.onAttemptFailed?.({
attempt,
decision,
nextDelayMs,
error,
});
if (nextDelayMs <= 0) {
throw new DeadlineExceededError({
cause: error,
});
}
await sleep(nextDelayMs, undefined, {
signal: input.signal,
});
}
}
throw lastError;
}
호출부는 timeout과 멱등성 키를 명시한다.
const result = await retry({
operation: async ({ signal }) => {
const response = await fetch(
"https://inventory.example.test/reservations",
{
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key":
command.idempotencyKey,
},
body: JSON.stringify({
productId: command.productId,
quantity: command.quantity,
}),
signal,
},
);
if (!response.ok) {
throw await HttpResponseError.from(response);
}
return response.json() as Promise<
ReservationResult
>;
},
policy: {
maxAttempts: 3,
baseDelayMs: 100,
maxDelayMs: 1000,
attemptTimeoutMs: 800,
minimumAttemptBudgetMs: 150,
responseReserveMs: 100,
},
deadline: {
expiresAt: Date.now() + 2500,
},
signal: requestSignal,
classify: classifyHttpFailure,
onAttemptFailed: (event) => {
logger.warn(
{
attempt: event.attempt + 1,
reason: event.decision.reason,
nextDelayMs: event.nextDelayMs,
},
"inventory request attempt failed",
);
},
});
실제 서비스에서는 사용 중인 HTTP 클라이언트가 연결·헤더·본문 timeout을 어떻게 구분하는지 확인하고, 이미 제공하는 retry 옵션과 중복되지 않게 한다.
재시도와 멱등성을 함께 설계한다
재시도 함수가 아무리 정교해도 부작용 중복을 해결하지는 않는다.
논리 작업마다 안정적인 키를 만든다
시도마다 새 키를 만들면 중복 제거가 되지 않는다.
// 잘못된 예: 매 시도마다 다른 키
for (let attempt = 0; attempt < 3; attempt += 1) {
const key = randomUUID();
await createOrder({ idempotencyKey: key });
}
상위 명령이 시작될 때 한 번 만든 키를 모든 시도에서 재사용한다.
const command = {
idempotencyKey:
`checkout:${checkoutId}:create-order`,
checkoutId,
};
await retry({
operation: ({ signal }) =>
orderApi.create(command, { signal }),
// ...
});
응답을 잃어도 결과를 조회할 수 있게 한다
GET /operations/{idempotencyKey}
timeout 뒤 무조건 새 작업을 만들기보다 같은 키의 상태를 조회한다.
unknown → 상태 조회
├─ succeeded: 기존 결과 반환
├─ processing: 잠시 후 조회
└─ not_found: 정책에 따라 재요청
키 보존 기간을 재시도 기간보다 길게 둔다
비동기 큐가 24시간 뒤 재전달할 수 있는데 서버가 키를 1시간만 보존하면 늦은 재시도가 새 작업으로 처리된다. 운영 재처리와 고객 요청의 최대 기간을 함께 고려한다.
자세한 구현은 재시도 가능한 API에 Idempotency-Key 적용하기에 이어진다.
관측과 테스트
재시도는 최종 성공만 보면 숨겨진 장애가 된다.
사용자 응답: 200
내부: 첫 시도 실패, 두 번째 timeout, 세 번째 성공
성공률은 정상으로 보여도 지연과 의존성 부하는 악화될 수 있다.
로그
각 시도의 실패를 구조화하되 비밀값과 전체 응답 본문은 남기지 않는다.
{
"dependency": "inventory-api",
"operation": "reserve",
"attempt": 2,
"reason": "temporary_upstream_failure",
"nextDelayMs": 437,
"remainingDeadlineMs": 1280,
"requestId": "req_example",
"message": "dependency attempt failed"
}
최종 실패 로그와 중복되지 않게 중간 시도는 debug나 샘플링된 warn으로 조절할 수 있다.
메트릭
- 최초 시도 성공률
- 재시도 후 최종 성공률
- 시도 횟수 분포
- 재시도 이유별 횟수
- 누적 백오프 시간
- deadline 소진 비율
- 대상 서비스별 재시도 예산 소모
- 멱등성 중복 적중 횟수
Request ID나 Idempotency Key를 메트릭 라벨로 쓰지 않는다.
지터 단위 테스트
무작위 함수를 주입하면 결정적으로 검증할 수 있다.
it("uses full jitter within the exponential cap", () => {
const delay = fullJitterDelay({
attempt: 2,
baseDelayMs: 100,
maxDelayMs: 10_000,
random: () => 0.5,
});
// cap = 100 * 2^2 = 400
expect(delay).toBe(200);
});
it("caps the exponential delay", () => {
const delay = fullJitterDelay({
attempt: 20,
baseDelayMs: 500,
maxDelayMs: 30_000,
random: () => 0.999,
});
expect(delay).toBeLessThan(30_000);
expect(delay).toBeGreaterThanOrEqual(0);
});
오류 분류 테스트
it.each([
[400, false],
[401, false],
[429, true],
[502, true],
[503, true],
[504, true],
])(
"classifies HTTP %s",
(status, expectedRetry) => {
const decision = classifyHttpFailure(
new HttpResponseError(status),
);
expect(decision.retry).toBe(expectedRetry);
},
);
부하 테스트
수백 클라이언트가 동시에 503을 받을 때 호출 시점 분포를 본다.
검증 항목
- 재시도가 특정 밀리초에 몰리지 않는가?
- 원래 요청 대비 총 시도 배수가 예상 범위인가?
- SDK 내부 재시도와 중복되지 않는가?
- 장애 중 연결 풀과 메모리가 안정적인가?
- 서버 회복 후 트래픽이 점진적으로 돌아오는가?
운영 체크리스트
마무리
재시도는 일시적인 실패를 감추는 간단한 반복문이 아니라 부하와 지연을 교환해 성공 가능성을 높이는 복원력 정책이다.
다음 시도에서 회복될 수 있고 같은 작업을 다시 수행해도 안전할 때만 재시도한다. 지수 백오프로 시도 빈도를 낮추고 지터로 클라이언트의 시점을 분산하며, 최대 횟수와 전체 deadline으로 비용을 제한한다.
재시도 가능한 오류 분류, Idempotency Key, 취소 신호, Retry-After, 여러 계층의 증폭까지 함께 다뤄야 한다. 실패율이 계속 높을 때는 재시도를 이어가기보다 호출 자체를 잠시 차단하는 Circuit Breaker가 다음 방어선이 된다.
참고 자료
- AWS Well-Architected: Control and limit retry calls
- Google Cloud Storage Retry Strategy
- Node.js Timers
- Node.js HTTP